package org.dayatang.i18n.impl;

import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils;

import java.text.MessageFormat;
import java.util.*;

public class ResourceBundleI18nService extends AbstractI18nService {

	private String[] basenames = new String[0];

	private ClassLoader bundleClassLoader;

	private ClassLoader beanTypeLoader = getDefaultClassLoader();

	/**
	 * Cache to hold loaded ResourceBundles. This Map is keyed with the bundle
	 * basename, which holds a Map that is keyed with the Locale and in turn
	 * holds the ResourceBundle instances. This allows for very efficient hash
	 * lookups, significantly faster than the ResourceBundle class's own cache.
	 */
	private final Map<String, Map<Locale, ResourceBundle>> cachedResourceBundles = new HashMap<String, Map<Locale, ResourceBundle>>();

	/**
	 * Cache to hold already generated MessageFormats. This Map is keyed with
	 * the ResourceBundle, which holds a Map that is keyed with the message
	 * code, which in turn holds a Map that is keyed with the Locale and holds
	 * the MessageFormat values. This allows for very efficient hash lookups
	 * without concatenated keys.
	 * 
	 * @see #getMessageFormat
	 */
	private final Map<ResourceBundle, Map<String, Map<Locale, MessageFormat>>> cachedBundleMessageFormats = new HashMap<ResourceBundle, Map<String, Map<Locale, MessageFormat>>>();

	/**
	 * Set a single basename, following {@link java.util.ResourceBundle}
	 * conventions: essentially, a fully-qualified classpath location. If it
	 * doesn't contain a package qualifier (such as <code>org.mypackage</code>),
	 * it will be resolved from the classpath root.
	 * <p>
	 * Messages will normally be held in the "/lib" or "/classes" directory of a
	 * web shiro's WAR structure. They can also be held in jar files on
	 * the class path.
	 * <p>
	 * Note that ResourceBundle names are effectively classpath locations: As a
	 * consequence, the JDK's standard ResourceBundle treats dots as package
	 * separators. This means that "test.theme" is effectively equivalent to
	 * "test/theme", just like it is for programmatic
	 * <code>java.util.ResourceBundle</code> usage.
	 * 
	 * @see #setBasenames
	 * @see java.util.ResourceBundle#getBundle(String)
	 */
	public void setBasename(String basename) {
		setBasenames(new String[] { basename });
	}

	/**
	 * Set an array of basenames, each following
	 * {@link java.util.ResourceBundle} conventions: essentially, a
	 * fully-qualified classpath location. If it doesn't contain a package
	 * qualifier (such as <code>org.mypackage</code>), it will be resolved from
	 * the classpath root.
	 * <p>
	 * The associated resource bundles will be checked sequentially when
	 * resolving a message code. Note that message definitions in a
	 * <i>previous</i> resource bundle will override ones in a later bundle, due
	 * to the sequential lookup.
	 * <p>
	 * Note that ResourceBundle names are effectively classpath locations: As a
	 * consequence, the JDK's standard ResourceBundle treats dots as package
	 * separators. This means that "test.theme" is effectively equivalent to
	 * "test/theme", just like it is for programmatic
	 * <code>java.util.ResourceBundle</code> usage.
	 * 
	 * @see #setBasename
	 * @see java.util.ResourceBundle#getBundle(String)
	 */
	public void setBasenames(String[] names) {
		if (names == null) {
			this.basenames = new String[0];
		} else {
			this.basenames = Arrays.copyOf(names, names.length);
		}
		for (int i = 0; i < basenames.length; i++) {
			String basename = basenames[i];
			if (StringUtils.isEmpty(basename) || StringUtils.isBlank(basename)) {
				throw new IllegalArgumentException(
						"Basename must not be null or empty");
			}
			this.basenames[i] = basename.trim();
		}
	}

	/**
	 * Set the ClassLoader to load resource bundles with.
	 * <p>
	 * Default is the containing BeanFactory's
	 * {@link org.springframework.beans.factory.BeanClassLoaderAware bean
	 * ClassLoader}, or the default ClassLoader determined by
	 * {@link org.springframework.util.ClassUtils#getDefaultClassLoader()} if
	 * not running within a BeanFactory.
	 */
	public void setBundleClassLoader(ClassLoader classLoader) {
		this.bundleClassLoader = classLoader;
	}

	/**
	 * Return the ClassLoader to load resource bundles with.
	 * <p>
	 * Default is the containing BeanFactory's bean ClassLoader.
	 * 
	 * @see #setBundleClassLoader
	 */
	protected ClassLoader getBundleClassLoader() {
		return (this.bundleClassLoader != null ? this.bundleClassLoader
				: this.beanTypeLoader);
	}

	public void setBeanClassLoader(ClassLoader classLoader) {
		this.beanTypeLoader = (classLoader != null ? classLoader
				: getDefaultClassLoader());
	}

	/**
	 * Resolves the given message code as key in the registered resource
	 * bundles, returning the value found in the bundle as-is (without
	 * MessageFormat parsing).
	 */
	@Override
	protected String resolveCodeWithoutArguments(String code, Locale locale) {
		String result = null;
		for (int i = 0; result == null && i < this.basenames.length; i++) {
			ResourceBundle bundle = getResourceBundle(this.basenames[i], locale);
			if (bundle != null) {
				result = getStringOrNull(bundle, code);
			}
		}
		return result;
	}

	/**
	 * Resolves the given message code as key in the registered resource
	 * bundles, using a cached MessageFormat instance per message code.
	 */
	@Override
	protected MessageFormat resolveCode(String code, Locale locale) {
		MessageFormat messageFormat = null;
		for (int i = 0; messageFormat == null && i < this.basenames.length; i++) {
			ResourceBundle bundle = getResourceBundle(this.basenames[i], locale);
			if (bundle != null) {
				messageFormat = getMessageFormat(bundle, code, locale);
			}
		}
		return messageFormat;
	}

	/**
	 * Return a ResourceBundle for the given basename and code, fetching already
	 * generated MessageFormats from the cache.
	 * 
	 * @param basename
	 *            the basename of the ResourceBundle
	 * @param locale
	 *            the Locale to find the ResourceBundle for
	 * @return the resulting ResourceBundle, or <code>null</code> if none found
	 *         for the given basename and Locale
	 */
	protected ResourceBundle getResourceBundle(String basename, Locale locale) {
		synchronized (this.cachedResourceBundles) {
			Map<Locale, ResourceBundle> localeMap = this.cachedResourceBundles
					.get(basename);
			if (localeMap != null) {
				ResourceBundle bundle = localeMap.get(locale);
				if (bundle != null) {
					return bundle;
				}
			}
			try {
				ResourceBundle bundle = doGetBundle(basename, locale);
				if (localeMap == null) {
					localeMap = new HashMap<Locale, ResourceBundle>();
					this.cachedResourceBundles.put(basename, localeMap);
				}
				localeMap.put(locale, bundle);
				return bundle;
			} catch (MissingResourceException ex) {
				if (logger.isWarnEnabled()) {
					logger.warn("ResourceBundle [" + basename
							+ "] not found for MessageSource: "
							+ ex.getMessage());
				}
				// Assume bundle not found
				// -> do NOT throw the exception to allow for checking parent
				// message source.
				return null;
			}
		}
	}

	/**
	 * Obtain the resource bundle for the given basename and Locale.
	 * 
	 * @param basename
	 *            the basename to look for
	 * @param locale
	 *            the Locale to look for
	 * @return the corresponding ResourceBundle
	 * @throws MissingResourceException
	 *             if no matching bundle could be found
	 * @see java.util.ResourceBundle#getBundle(String, java.util.Locale,
	 *      ClassLoader)
	 * @see #getBundleClassLoader()
	 */
	protected ResourceBundle doGetBundle(String basename, Locale locale)
			throws MissingResourceException {
		return ResourceBundle.getBundle(basename, locale,
				getBundleClassLoader());
	}

	/**
	 * Return a MessageFormat for the given bundle and code, fetching already
	 * generated MessageFormats from the cache.
	 * 
	 * @param bundle
	 *            the ResourceBundle to work on
	 * @param code
	 *            the message code to retrieve
	 * @param locale
	 *            the Locale to use to build the MessageFormat
	 * @return the resulting MessageFormat, or <code>null</code> if no message
	 *         defined for the given code
	 * @throws MissingResourceException
	 *             if thrown by the ResourceBundle
	 */
	protected MessageFormat getMessageFormat(ResourceBundle bundle,
			String code, Locale locale) throws MissingResourceException {

		synchronized (this.cachedBundleMessageFormats) {
			Map<String, Map<Locale, MessageFormat>> codeMap = this.cachedBundleMessageFormats
					.get(bundle);
			Map<Locale, MessageFormat> localeMap = null;
			if (codeMap != null) {
				localeMap = codeMap.get(code);
				if (localeMap != null) {
					MessageFormat result = localeMap.get(locale);
					if (result != null) {
						return result;
					}
				}
			}

			String msg = getStringOrNull(bundle, code);
			if (msg != null) {
				if (codeMap == null) {
					codeMap = new HashMap<String, Map<Locale, MessageFormat>>();
					this.cachedBundleMessageFormats.put(bundle, codeMap);
				}
				if (localeMap == null) {
					localeMap = new HashMap<Locale, MessageFormat>();
					codeMap.put(code, localeMap);
				}
				MessageFormat result = createMessageFormat(msg, locale);
				localeMap.put(locale, result);
				return result;
			}

			return null;
		}
	}

	private String getStringOrNull(ResourceBundle bundle, String key) {
		try {
			return bundle.getString(key);
		} catch (MissingResourceException ex) {
			// Assume key not found
			// -> do NOT throw the exception to allow for checking parent
			// message source.
			return null;
		}
	}

	private ClassLoader getDefaultClassLoader() {
		ClassLoader cl = null;
		try {
			cl = Thread.currentThread().getContextClassLoader();
		} catch (Exception ex) {
			// Cannot access thread context ClassLoader - falling back to system
			// class loader...
		}
		if (cl == null) {
			// No thread context class loader -> use class loader of this class.
			cl = ResourceBundleI18nService.class.getClassLoader();
		}
		return cl;
	}

	/**
	 * Show the configuration of this MessageSource.
	 */
	@Override
	public String toString() {
		return getClass().getName() + ": basenames=" + ArrayUtils.toString(basenames) ;
	}

}
